Java进阶 - JVM 内存管理机制探秘

内容概述

  • JVM 运行时数据区概述
  • JVM 对象分配、布局与访问过程
  • GC机制与内存分配策略
  • 主要以 HotSpot JVM 为例进行说明

JVM 运行时数据区概述

HotSpot 运行时数据区

  • 程序计数器(线程私有):当前线程所执行执行的字节码行号指示器
    JVM概念模型中,字节码解析器会通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程回复等基础功能都需要该计数器完成。
    由于JVM多线程通过线程轮切(并发)实现,因此各线程之间计数器互相独立。
  • Java虚拟机栈(线程私有):生命周期与线程相同,描述Java方法执行的内存模型 – 每个方法执行时候都会创建一个栈帧(存有局部变量表、操作数、动态连接、方法出口等信息),每个方法调用到执行完毕,对应着一个栈帧在虚拟机中入栈到出栈过程。
    注意:局部变量表中放的是基本数据类型以及指向对象的句柄或地址
    如果线程请求栈深度超过JVM允许范围,则会抛出 StackOverflowError 异常;
    如果在动态扩展虚拟机栈时候(现在大多数JVM都可以动态扩展),内存不足,则是OOM异常;
  • 本地方法栈(线程私有):与虚拟机栈相似,区别:虚拟机栈为虚拟机执行Java方法(字节码)服务;本地方法栈则为虚拟机使用到的Native方法服务。
  • Java堆(线程共享):所有对象实例以及数组都要在堆上分布(注意:随技术发展,使得不再“绝对”);Java堆是GC管理机制的主要区域
    Java堆可以处于物理上不连续的内存空间,只要逻辑上连续即可;
    无法扩展时候,会抛出OOM异常
    堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。
    新生代:Young Generation,主要用来存放新生的对象。
    老年代:Old Generation或者称作Tenured Generation,主要存放应用程序声明周期长的内存对象。
  • 方法区(线程共享):存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,另外还存有一项常量池信息,存放于运行时常量池
    注意:运行时常量池是方法区的一部分 – 用于存放编译期生成的各种字面量和符号引用
  • 直接内存(拓展,不属于虚拟机运行时数据区,但被频繁使用,有可能导致OOM):JDK 1.4 中加入 NIO 类(New I/O)(引入一种基于通道与缓冲区的I/O方式,可以使用 Native 函数直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作,避免了 Java 堆和 Native 堆中来回复制数据,故显著提高性能),本机直接内存不会受到 Java 堆大小的限制(当然还是受本机内存限制)

JVM 虚拟机对象分配、布局与访问过程

对象创建 - new 指令过程

  1. 类加载检查:检查指令中的参数能否在常量池中定位到一个类的符号引用,并且检查该引用的类是否已经被加载、解析与初始化过,若没有,则必须执行相应的类加载过程。
  2. 分配内存:对象所需内存大小在类加载后就完全确定,分配空间等同于将一块确定大小的内存从 Java 堆中划分出来。(指针碰撞/空闲列表 - 取决于内存是否规整,内存是否规整又取决于采用的GC机制是否有压缩整理功能)
    科普 - 指针碰撞:仅把指针向空闲空间方向挪动一段与对象大小相等的距离(通常用于Java 堆中内存规整的情况下)。
    科普 - 空闲列表:一个记录着哪些内存块可用的列表,分配时,从列表中找一块足够大的内存块划分给对象,并更新表记录(通常用于Java 堆中内存不规整的情况下
  3. CAS + 失败重试方式保证更新操作的原子性:对象创建在虚拟机中非常频繁的情况下,并发情况下也并不是线程安全的即时是简单的修改指针操作。
  4. 初始化数值:内存分配完成后,虚拟机需要将分配到的内存空间进行初始化零值,从而保证对象的实例字段不用赋值也能直接使用。
  5. 设置对象:根据虚拟机当前的运行状态不同,是否启用偏向锁等,对象头会有不同的设置方式。
  6. 执行 init 方法:将对象按照程序员意愿进行初始化(虚拟机角度已经完成对象创建,但对于程序员则是刚刚开始)

对象的内存布局

可分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

  1. 对象头(包括两部分信息)
    Part 1:存储“Mark Word” - 对象自身运行时数据(HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳);
    Part 2:类型指针,对象指向它的类元数据的指针,虚拟机会通过这个指针来确定该对象是哪个类的实例,但并不是所有虚拟机实现都必须在对象数据上保留类型指针(即:查找元数据不一定要经过对象本身
  2. 实例数据:对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容(父类继承的、子类中定义的都要记录)。
    这部分的存储顺序会受到虚拟机分配策略参数(FieldAllocationStyle)和字段在 Java 源码中定义顺序的影响。
  3. 对齐填充:并不必然存在,没有特别的含有,仅仅起占位符作用(HotSpot 虚拟机要求对象起始地址必须是8字节的整数倍 – 对象大小必须是8字节的整数倍,对象头部分正好的8的整数倍,而实例数据部分没有对齐,就通过对齐填充来对齐)

对象的访问定位

主流的访问方式:句柄、直接指针

  1. 句柄:java 堆中将划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息
    优势:reference 中存储的是稳定的句柄地址,在对象被移动时(GC机制会移动对象),只会改变句柄中的实例数据指针,reference 本身不需要修改。(但需要指针定位两次)
  2. 指针访问方式:reference变量中直接存储的就是对象的地址,而java堆对象一部分存储了对象实例数据,另外一部分存储了对象类型数据。
    优势:速度快,节省一次指针定位的时间开销,由于对象访问频繁,积小成多后,节省的开销还是很客观的(HotSpot使用该种方法)

GC机制与内存分配策略

程序计数器、虚拟机栈、本地方法栈是线程私有的,因此会随线程生命周期而存在,内存的分配与回收具有确定性,因此不需要过多去考虑回收。
Java 栈、方法区是所有线程共享的,只有在程序运行时,才知道会创建什么对象,因此创建与回收都是动态的。GC机制所关注的是这部分内存。

引用计算法

主流的 Java 虚拟机并没有采用引用计算法!原因:很难解决对象之间相互循环引用的问题。
概述:给对象添加一个引用计数器,产生引用时,计数器+1;引用失效时,计数器-1;任何时刻引用为0时,认为对象不可能被引用。

循环引用问题 – 为何不采用?
例子:objA.instance = objB; objB.instance = objA; 除此以外,再没有其他引用,但引用计算法计数器并不为0,因为他们相互引用对方,因此采用引用计算法会导致GC收集器无法回收该类型的对象。

可达性分析算法

基本思想:通过一系列称为“GC Roots”的对象作为起点,开始向下搜寻,搜寻走过的路径称“引用链”,当一个对象到 GC Roots 没有任何引用链相连(即不可达),则认为可回收对象。
GC Roots 对象的选取:1.虚拟机栈(栈帧中的本地变量表)中引用的对象 2.方法区中类静态属性引用的对象 3.方法区中常量引用的对象 4.本地方法栈中 JNI(即 Native 方法) 引用对象。

聚焦引用

上述两种方法,判断对象是否“存活”的关键都在于引用
JDK 1.2 之后,引用分为(强 -> 弱 排序):
强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)

  1. 强引用:普遍存在。GC机制永远不会回收被强引用的对象。例:“Object obj = new Object();”
  2. 软引用:还有用,但非必须。系统即将OOM时,就会把这些对象列入回收范围。使用 SoftReference 类来实现。
  3. 弱引用:非必须,强度比软引用更弱。无论内存是否足够,只能生存到下一次GC之前。使用WeakReference 类来实现。
  4. 虚引用:不影响被引用对象的生存时间,生存角度上,相当于没有加引用,只是为了能在该对象被GC回收时候收到通知。使用PhantomReference 类来实现。

判定死亡与自我拯救

可达性分析算法中,至少需要经历两次标记过程,才会真正宣告一个对象的死亡。(并不是一次不可达就直接进行GC回收)
过程

  1. 当对象第一次判定不可达(没有与 GC Roots 对象引用链相连),它将会被第一次标记并进行一次筛选
  2. 筛选:该对象是否有必要执行 finalize() 方法。
    “没有必要执行”的两种情况:
    1.对象没有覆盖 finalize() 方法;
    2.finalize() 方法已经被虚拟机调用过.
  3. 若“有必要执行 finalize()”:对象会被加入 F-Queue 中,随后由一个虚拟机自动建立的、低优先级的 Finalize 线程去执行(触发对象的finalize() 方法),但虚拟机不会等到它运行结束(防止对象的 finalize() 方法执行缓慢,导致 F-Queue 其他对象过久等待)。
    finalize() 方法是对象最后一次自我拯救的机会,Finalize 线程触发对象的 finalize()方法后,GC将会对 F-Queue 中的对象进行第二次小规模标记,如果对象仍然是无关联(不可达),那么对象就真的被判定死亡了(GC回收);而如果对象建立了关联,本次标记就会把对象移除“即将回收”的集合,逃离GC回收。
    自我拯救方法:在 finalize() 方法中重新与引用链上任意一个对象建立关联即可(并不推荐这么做,应该尽量避免,该方法是为了让C/C++程序员更好接受Java所做出的妥协,作为Java程序员,我们大可将它直接忘掉)
  4. 注意:任意一个对象的 finalize() 方法只会被系统自动调用一次,当对象面临下一次回收时候,finalize() 方法就不会再次执行。

方法区的回收

HotSpot 虚拟机中的永生代 - 即方法区:是指内存的永久保存区域,主要存放 Class、Meta 的信息,Class 在被 Load 的时候被放入 PermGen space 区域. 它和存放 Instance 的 Heap 区域不同,GC不会在主程序运行期对PermGen space进行清理,所以如果你的应用会加载很多Class的话,就很可能出现PermGen space错误。

java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法去中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,而永久代的垃圾收集效率远低于此。

主要回收两个内容:废弃的常量和无用的类

废弃常量回收方法:类似 Java 堆中的对象回收,没有引用的时候,就进行回收。(书本上的例子:加入一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做”abc”的,换句话说,就是有任何String对象应用常量池中的”abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。注意:JDK 1.7 及以后的版本已经将字符串常量池从永久代中移除

如何判断无用的类? – 满足以下三个条件:

  1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
  2. 加载该类的 ClassLoader 已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述三个条件的无用类进行回收,这里说的仅仅是“可以”,而并不和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了 -Xnoclassgc 参数进行控制。(如果关闭CLASS的垃圾回收功能,就是虚拟机加载的类,即便是不使用,没有实例也不会回收)

另外:在大量使用反射、动态代理、CGlib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出

书目推荐

  • 强势推荐 《深入理解 Java 虚拟机》,由浅入深并结合实践,十分优秀的一本书!
感谢您的阅读,希望文章对您有所帮助